defmodule Zig.Module do
  @moduledoc false
  # abstraction representing multiple zig nif functions bound into a single
  # module

  alias Zig.Builder
  alias Zig.C
  alias Zig.DepsModule
  alias Zig.BuildModule
  alias Zig.Nif
  alias Zig.Options
  alias Zig.Resources
  alias Zig.Sema
  alias Zig.Type.Error

  # you'll never see me ever import any other module.
  import Zig.QuoteErl

  @enforce_keys [:otp_app, :module, :file, :line, :optimize, :error_tracing]

  defstruct @enforce_keys ++
              [
                # user options:
                :module_code_path,
                :zig_code_path,
                :version,
                :easy_c,
                :fingerprint,
                :dir,
                :c,
                :nifs,
                :build_files_dir,
                :precompiled,
                # generated by zigler
                :parsed,
                :manifest,
                :manifest_module,
                :sema,
                :sema_json,
                :libc_txt,
                # defaulted options
                ignore: [],
                extra_modules: [],
                dependencies: [],
                resources: [],
                callbacks: [],
                # assembled from collected `opts` fields
                default_nif_opts: [],
                # generated by zigler
                language: Elixir,
                external_resources: [],
                attributes: [],
                # debug options:  user-supplied, but unchecked.
                dump: false,
                dump_sema: false,
                dump_build_zig: false
              ]

  @type t :: %__MODULE__{
          otp_app: atom,
          module: module,
          file: Path.t(),
          line: pos_integer,
          module_code_path: nil | Path.t(),
          zig_code_path: nil | Path.t(),
          version: String.t(),
          easy_c: nil | Path.t(),
          fingerprint: String.t(),
          manifest: nil | Manifest.t(),
          manifest_module: nil | module,
          dir: Path.t(),
          c: C.t(),
          nifs: {:auto, [Nif.t()]} | [Nif.t()],
          build_files_dir: nil | Path.t(),
          precompiled: nil | Path.t() | term,
          parsed: Parser.t(),
          manifest: nil | Manifest.t(),
          manifest_module: nil | module,
          sema: Sema.info(),
          sema_json: String.t(),
          libc_txt: nil | Path.t(),
          optimize: Zig.optimize(),
          error_tracing: boolean,
          ignore: [atom],
          extra_modules: [BuildModule.t()],
          dependencies: keyword(),
          resources: [atom],
          callbacks: [on_load: atom, on_upgrade: atom, on_unload: atom],
          default_nif_opts: [cleanup: boolean, leak_check: boolean],
          language: Elixir | :erlang,
          external_resources: [Path.t()],
          attributes: keyword,
          dump: boolean,
          dump_sema: boolean,
          dump_build_zig: boolean | :stdout | :stderr | Path.t()
        }

  @defaultable_nif_opts ~w[cleanup leak_check]a

  use Zig.Builder, template: "templates/build_mod.zig.eex"

  @affix %{"Elixir": "`use Zig`", erlang: "`zig_opts(...)`"}

  # this function builds and verifies the module options and turns it into the struct.

  def new(opts, caller) do
    # make sure that the caller has declared otp_app here.
    context =
      case Keyword.fetch(opts, :otp_app) do
        :error ->
          lang = Keyword.fetch!(opts, :language)

          raise CompileError,
            description:
              "(module #{inspect(caller.module)}) you must supply an `otp_app` option to #{@affix[lang]}",
            file: caller.file,
            line: caller.line

        {:ok, otp_app} ->
          Options.initialize_context(caller, otp_app)
      end

    default_nif_opts = Keyword.take(opts, @defaultable_nif_opts)

    common_values = [
      module: caller.module,
      file: caller.file,
      line: caller.line,
      default_nif_opts: default_nif_opts
    ]

    opts
    |> obtain_version
    |> Options.normalize_kw(:c, %C{}, Options.struct_normalizer(C), context)
    |> Options.normalize_kw(
      :callbacks,
      [],
      list_normalizer(&normalize_callback/2, "callback"),
      context
    )
    |> Options.normalize_kw(
      :extra_modules,
      [],
      list_normalizer(&normalize_extra_modules/2, "module"),
      context
    )
    |> Options.normalize_kw(
      :dependencies,
      [],
      list_normalizer(&normalize_dependency/2, "dependency"),
      context
    )
    |> validate_dependency_modules!(context)
    |> Options.normalize(:precompiled, &normalize_precompiled/2, context)
    |> Options.normalize(:optimize, &normalize_optimize/2, context)
    |> Options.normalize_path(:dir, context)
    |> Options.normalize_path(:module_code_path, context)
    |> Options.normalize_path(:zig_code_path, context)
    |> normalize_easy_c(context)
    |> Options.normalize_kw(:ignore, [], &normalize_atom_or_atomlist/2, context)
    |> Options.normalize_kw(:resources, [], &normalize_atom_or_atomlist/2, context)
    |> Options.validate(:cleanup, :boolean, context)
    |> Options.validate(:leak_check, :boolean, context)
    |> Options.validate(:error_tracing, :boolean, context)
    |> Keyword.drop(@defaultable_nif_opts)
    |> Keyword.merge(common_values)
    |> normalize_nifs(context)
    |> set_default_optimize()
    |> set_error_tracing()
    |> then(&struct!(__MODULE__, &1))
  rescue
    e in KeyError ->
      context = Options.initialize_context(caller, nil)
      lang = Keyword.fetch!(opts, :language)
      Options.raise_with("#{@affix[lang]} was supplied the invalid option `#{e.key}`", context)
  end

  defp obtain_version(opts) do
    otp_app = Keyword.fetch!(opts, :otp_app)

    Keyword.put_new_lazy(opts, :version, fn ->
      cond do
        # try checking the mix project first (this is if the project is being compiled for the first time)
        version = function_exported?(Mix.Project, :config, 0) and Mix.Project.config()[:version] ->
          Version.parse!(version)

        # try checking the application version (this is if we are hot-patching the so file)
        tuple = Application.loaded_applications() |> List.keyfind(otp_app, 0) ->
          tuple
          |> elem(2)
          |> to_string()
          |> Version.parse!()

        :else ->
          Version.parse!("0.0.0")
      end
    end)
  end

  defp list_normalizer(callback, error_msg),
    do: fn
      list, context when is_list(list) ->
        try do
          Enum.flat_map(list, &List.wrap(if d = callback.(&1, context), do: d))
        rescue
          _ in FunctionClauseError ->
            Options.raise_with(
              "must be a list of #{error_msg} specifications",
              list,
              context
            )
        end

      other, context ->
        Options.raise_with(
          "must be a list of #{error_msg} specifications",
          other,
          context
        )
    end

  @callbacks ~w[on_load on_upgrade on_unload]a

  defp normalize_callback(callback, _context) when callback in @callbacks,
    do: {callback, callback}

  defp normalize_callback({callback, fun}, _context) when callback in @callbacks and is_atom(fun),
    do: {callback, fun}

  defp normalize_callback({callback, invalid}, context) when callback in @callbacks,
    do: Options.raise_with("must be an atom", invalid, Options.push_key(context, callback))

  defp normalize_callback(other, context),
    do:
      Options.raise_with(
        "must be a keyword list of callback specs",
        other,
        context
      )

  @dependencies_atom_error "must be a list of atoms representing dependencies"
  @module_tuple_error "must be a tuple of the form `{path, [deps...]}`"

  defp normalize_extra_modules({k, {path, deps}}, context) when is_atom(k) and is_list(deps) do
    Enum.each(deps, fn dep ->
      is_atom(dep) or
        Options.raise_with(@dependencies_atom_error, dep, Options.push_key(context, k))
    end)

    try do
      %BuildModule{
        name: k,
        path: Zig._normalize_path(path, Path.dirname(context.file)),
        deps: deps
      }
    rescue
      _ ->
        Options.raise_with(
          @module_tuple_error,
          {:tag, "path", path},
          Options.push_key(context, k)
        )
    end
  end

  defp normalize_extra_modules({k, {dep, module}}, _context)
       when is_atom(dep) and is_atom(module) do
    %DepsModule{dep: dep, src_mod: module, dst_mod: k}
  end

  defp normalize_extra_modules({k, {_, malformed}}, context) when is_atom(k),
    do: Options.raise_with(@dependencies_atom_error, malformed, Options.push_key(context, k))

  defp normalize_extra_modules({k, malformed}, context) when is_atom(k),
    do: Options.raise_with(@module_tuple_error, malformed, Options.push_key(context, k))

  defp normalize_extra_modules(other, opts),
    do:
      Options.raise_with(
        "must be a list of module specifications",
        other,
        opts
      )

  defp normalize_dependency({dep_name, path}, context) do
    path
    |> Zig._normalize_path(Path.dirname(context.file))
    |> then(&{dep_name, &1})
  end

  defp validate_dependency_modules!(opts, context) do
    dependencies = Keyword.get(opts, :dependencies, [])

    for %DepsModule{dep: dep, dst_mod: dst_mod} <- Keyword.get(opts, :extra_modules, []) do
      if not Keyword.has_key?(dependencies, dep) do
        context =
          context
          |> Options.push_key(:extra_modules)
          |> Options.push_key(dst_mod)

        Options.raise_with("requires the `#{dep}` dependency", dependencies, context)
      end
    end

    opts
  end

  defp normalize_precompiled(file, context) when is_binary(file) do
    File.exists?(file) or
      Options.raise_with("file #{file} does not exist", Options.push_key(context, :precompiled))

    if System.get_env("ZIGLER_PRECOMPILE_FORCE_RECOMPILE", "false") != "true", do: file
  end

  defp normalize_precompiled({:web, url, shasum}, context) do
    if not is_binary(url),
      do: Options.raise_with("url must be a binary", Options.push_key(context, :precompiled))

    if not (is_binary(shasum) or is_list(shasum)),
      do:
        Options.raise_with(
          "shasum must be a binary or keyword list",
          Options.push_key(context, :precompiled)
        )

    if shasum = normalize_shasum(shasum) do
      case Base.decode16(shasum, case: :mixed) do
        {:ok, _} ->
          if System.get_env("ZIGLER_PRECOMPILE_FORCE_RECOMPILE", "false") != "true",
            do: {:web, substitute_url(url, context), shasum}

        :error ->
          Options.raise_with(
            "shasum must be base-16 encoded",
            Options.push_key(context, :precompiled)
          )
      end
    end
  end

  defp normalize_precompiled(_, context) do
    Options.raise_with(
      "must be a path or `{:web, url, shasum}` tuple",
      Options.push_key(context, :precompiled)
    )
  end

  defp substitute_url(url, context) do
    url
    |> String.replace("#VERSION", "#{Application.spec(context.otp_app, :vsn)}")
    |> String.replace("#TRIPLE", "#{sysarch_to_key()}")
    |> String.replace("#EXT", if(match?({_, :nt}, :os.type()), do: "dll", else: "so"))
  end

  defp normalize_easy_c(opts, context) do
    if Keyword.has_key?(opts, :easy_c) do
      Keyword.update!(opts, :easy_c, &IO.iodata_to_binary/1)
    else
      opts
    end
  rescue
    _ ->
      Options.raise_with("must be a path", opts[:easy_c], Options.push_key(context, :easy_c))
  end

  @atom_or_atomlist_error "must be an atom or a list of atoms"

  defp normalize_atom_or_atomlist(atom, _context) when is_atom(atom), do: [atom]

  defp normalize_atom_or_atomlist(list, context) when is_list(list) do
    Enum.each(list, fn
      item ->
        is_atom(item) or Options.raise_with(@atom_or_atomlist_error, item, context)
    end)

    list
  rescue
    _ in FunctionClauseError -> Options.raise_with(@atom_or_atomlist_error, list, context)
  end

  defp normalize_atom_or_atomlist(other, context),
    do:
      Options.raise_with(
        @atom_or_atomlist_error,
        other,
        context
      )

  defp normalize_nifs(opts, context) do
    common_values = Keyword.take(opts, ~w[module file line module_code_path zig_code_path]a)

    Options.normalize_kw(
      opts,
      :nifs,
      {:auto, []},
      &normalize_nifs(&1, common_values, &2),
      context
    )
  end

  defp normalize_nifs(nifs, common_values, context) when is_list(nifs) do
    Enum.map(nifs, fn
      {atom, opts} when is_atom(atom) and is_list(opts) ->
        Nif.new({atom, common_values ++ opts}, context)

      atom when is_atom(atom) ->
        Nif.new({atom, common_values}, context)

      other ->
        Options.raise_with("", other, context)
    end)
  end

  defp normalize_nifs({:auto, nifs}, common_values, context) do
    {:auto, normalize_nifs(nifs, common_values, context)}
  end

  defp normalize_shasum(shasum) when is_binary(shasum), do: shasum

  defp normalize_shasum(kwl) do
    Keyword.get(kwl, sysarch_to_key())
  end

  @modes ~w[debug safe fast small]a
  @modes_str Enum.map(@modes, &"#{&1}")

  defguardp is_env_mode(mode)
            when mode == :env or
                   (is_tuple(mode) and tuple_size(mode) == 2 and elem(mode, 0) == :env)

  defp normalize_optimize(mode, _context) when mode in @modes, do: mode

  defp normalize_optimize(other, context) when is_env_mode(other) do
    case System.get_env("ZIGLER_RELEASE_MODE") do
      mode when mode in @modes_str -> String.to_existing_atom(mode)
      null when null in [nil, ""] -> default_optimize(other)
      other -> Options.raise_with("must be one of #{inspect(@modes)}", other, context)
    end
  end

  defp normalize_optimize(other, context) do
    Options.raise_with(
      "must be one of `:debug`, `:safe`, `:fast`, `:small`, `:env`, or `{:env, mode}`",
      other,
      context
    )
  end

  defp default_optimize(:env), do: nil
  defp default_optimize({:env, mode}), do: mode

  # CODE RENDERING

  # zig file rendering

  require EEx

  nif = Path.join(__DIR__, "templates/module.zig.eex")
  EEx.function_from_file(:def, :render_zig, nif, [:assigns])

  on_load = Path.join(__DIR__, "templates/on_load.zig.eex")
  EEx.function_from_file(:def, :render_on_load, on_load, [:assigns])

  on_upgrade = Path.join(__DIR__, "templates/on_upgrade.zig.eex")
  EEx.function_from_file(:def, :render_on_upgrade, on_upgrade, [:assigns])

  on_unload = Path.join(__DIR__, "templates/on_unload.zig.eex")
  EEx.function_from_file(:def, :render_on_unload, on_unload, [:assigns])

  # internal helpers for rendering the c
  defp table_entries(nifs) when is_list(nifs) do
    nifs
    |> Enum.flat_map(&Nif.table_entries/1)
    |> Enum.join(",")
  end

  @index_of %{major: 0, minor: 1}

  defp nif_version(at) do
    :nif_version
    |> :erlang.system_info()
    |> List.to_string()
    |> String.split(".")
    |> Enum.at(@index_of[at])
  end

  # ast rendering

  def render_elixir(module, zig_code) do
    module_name = "#{module.module}"

    on_load_code =
      if {:__on_load__, 0} in Module.definitions_in(module.module) do
        quote do
          __on_load__()
        end
      else
        0
      end

    external_resources =
      Enum.map(module.external_resources, fn file ->
        quote do
          @external_resource unquote(file)
        end
      end)

    load_nif_fn =
      quote do
        unquote_splicing(external_resources)

        def __load_nifs__ do
          # LOADS the nifs from :code.lib_dir() <> "ebin", which is
          # a path that has files correctly moved in to release packages.
          require Logger

          unquote(module.otp_app)
          |> :code.priv_dir()
          |> Path.join("lib")
          |> Path.join(unquote(module_name))
          |> String.to_charlist()
          |> :erlang.load_nif(unquote(on_load_code))
          |> case do
            :ok ->
              Logger.debug("loaded module at #{unquote(module_name)}")

            error = {:error, any} ->
              Logger.error("loading module #{unquote(module_name)} #{inspect(any)}")
          end
        end
      end

    function_code = Enum.map(module.nifs, &Nif.render_elixir/1)

    manifest_code =
      if Enum.any?(module.nifs, &match?(%Error{}, &1.return.type)) do
        quote do
          require Zig.Manifest
          Zig.Manifest.resolver(unquote(module.manifest), unquote(module.zig_code_path), :defp)
        end
      end

    quote do
      # these two attribs can be persisted for code inspection.
      @zigler_module unquote(Macro.escape(module))
      @zig_code unquote(zig_code)

      unquote_splicing(function_code)
      unquote(load_nif_fn)
      unquote(manifest_code)

      def _format_error(_, [{_, _, _, opts} | _rest] = _stacktrace) do
        if formatted = opts[:zigler_error], do: formatted, else: %{}
      end
    end
  end

  def render_erlang(module, _zig_code) do
    otp_app = module.otp_app
    module_name = Atom.to_charlist(module.module)

    init_function =
      quote_erl(
        """
        '__init__'() ->
          erlang:load_nif(filename:join(code:priv_dir(unquote(otp_app)), unquote(module_id)), []).
        """,
        otp_app: otp_app,
        module_id: ~C'lib/' ++ module_name
      )

    function_code = Enum.map(module.nifs, &Nif.render_erlang/1)

    Enum.flat_map(function_code, & &1) ++ init_function
  end

  # EEX Helper functions
  defp render_optimize_mode(build) do
    case build.optimize do
      :debug -> ".Debug"
      :safe -> ".ReleaseSafe"
      :fast -> ".ReleaseFast"
      :small -> ".ReleaseSmall"
    end
  end

  def render_c(nil, _), do: ""
  def render_c(%DepsModule{}, _), do: ""
  def render_c(%BuildModule{} = m, mode), do: render_c(m.c, mode)

  def render_c(%C{} = c, mode) do
    link_libs =
      Enum.map(c.link_lib, fn
        {:system, libname} ->
          "#{mode}.linkSystemLibrary(\"#{libname}\");"

        path ->
          "#{mode}.addObjectFile(.{.cwd_relative = \"#{path}\"});"
      end)

    c_srcs =
      Enum.map(c.src, fn
        {c_src, flags} ->
          """
          #{mode}.addCSourceFiles(.{
            .root = .{.cwd_relative = "#{Path.dirname(c_src)}"},
            .files = &.{"#{Path.basename(c_src)}"},
            .flags = &.{ #{render_flags(flags)}},
          });
          """
      end)

    link_libs ++ c_srcs
  end

  defp render_flags(flags) do
    Enum.map_join(flags, ", ", &~s("#{&1}\"))
  end

  defp escape(string) do
    String.replace(string, "\"", "\\\"")
  end

  defp sysarch_to_key do
    :system_architecture
    |> :erlang.system_info()
    |> IO.iodata_to_binary()
    |> String.split("-")
    |> case do
      [arch, "apple" | _] -> :"#{arch}-macos-none"
      [arch, _, "freebsd" | _] -> :"#{arch}-freebsd-none"
      ["i386", _vendor, os, abi] -> :"x86-#{os}-#{abi}"
      ["i686", _, os, abi] -> :"x86-#{os}-#{abi}"
      [arch, _vendor, os, abi] -> :"#{arch}-#{os}-#{abi}"
    end
  end

  defp set_default_optimize(opts) do
    if opts[:optimize] do
      opts
    else
      Keyword.put(opts, :optimize, default_optimize())
    end
  end

  defp default_optimize do
    if function_exported?(Mix, :env, 0) and Mix.env() in ~w[dev test]a, do: :debug, else: :safe
  end

  defp set_error_tracing(opts) do
    cond do
      !Zig._errors_available?() ->
        Keyword.put(opts, :error_tracing, false)

      !Code.compiler_options()[:debug_info] ->
        Keyword.put(opts, :error_tracing, false)

      Keyword.has_key?(opts, :error_tracing) ->
        opts

      :else ->
        should_trace = Keyword.fetch!(opts, :optimize) in ~w[debug safe]a
        Keyword.put(opts, :error_tracing, should_trace)
    end
  end
end
